Next.js 기반의 대규모 모노레포에서 React Query 레이어를 구축하여 props drilling을 제거하고 SSR/CSR 환경을 통합 처리한 경험을 공유합니다. Provider level에서 하이드레이트 대응으로 사용처의 react-query 디펜던시를 제거했습니다.
📊 프로젝트 개요
배경
- 프레임워크: Next.js 14, React 18
- 목표: props drilling 제거 및 SSR/CSR 환경 통합 처리
- 성과: 사용처 react-query 디펜던시 제거, 모듈 독립성 확보
핵심 성과
- ✅ Provider level에서 하이드레이트 대응
- ✅ prefetch/query/useQuery를 이용한 props drilling 제거
- ✅ 쿼리 키와 queryFn을 함께 묶어서 사용하여 캐시 미스 방지
- ✅ 사용처 react-query 디펜던시 제거
- ✅ SSR에 컴포넌트를 유연하게 대응
- ✅ 모듈 독립성 확보
🔴 문제 상황
1. Props Drilling 문제
문제:
- 데이터를 여러 컴포넌트 레벨을 거쳐 전달해야 함
- 중간 컴포넌트들이 불필요한 props를 받아야 함
- 코드 가독성 저하 및 유지보수 어려움
BOLD_PAREN_PLACEHOLDER_0:
// 페이지 컴포넌트
export default function HomePage({ initialData }) {
return (
<Layout>
<Header userData={initialData.user} />
<MainContent
products={initialData.products}
userData={initialData.user}
/>
<Footer />
</Layout>
)
}
// 중간 컴포넌트
function MainContent({ products, userData }) {
return (
<div>
<ProductList products={products} userData={userData} />
<RecommendationSection userData={userData} />
</div>
)
}
// 실제 사용 컴포넌트
function ProductList({ products, userData }) {
// userData를 사용하지 않지만 props로 받아야 함
return <div>{/* products만 사용 */}</div>
}
문제점:
userData를 사용하지 않는 컴포넌트도 props로 받아야 함- 중간 컴포넌트들이 불필요한 props 전달 역할만 함
- 타입 정의가 복잡해짐
2. SSR/CSR 환경 분리 문제
문제:
- SSR 환경에서는
getStaticProps에서 데이터를 가져와야 함 - CSR 환경에서는
useQuery를 사용해야 함 - 같은 데이터를 두 가지 방식으로 처리해야 함
BOLD_PAREN_PLACEHOLDER_1:
// SSR 환경
export async function getStaticProps() {
const products = await fetchProducts()
const user = await fetchUser()
return {
props: {
initialData: { products, user },
},
}
}
// CSR 환경
function Component() {
const { data: products } = useQuery('products', fetchProducts)
const { data: user } = useQuery('user', fetchUser)
// 같은 로직을 두 번 작성해야 함
}
문제점:
- 같은 데이터를 두 가지 방식으로 처리
- 코드 중복 발생
- 환경 전환 시 수정 범위 큼
3. 사용처 react-query 디펜던시 문제
문제:
- 모듈을 사용하는 곳에서 react-query를 직접 import해야 함
- 모듈과 사용처 간 결합도 증가
- 모듈 독립성 저하
BOLD_PAREN_PLACEHOLDER_2:
// 모듈 사용처
import { useQuery } from '@tanstack/react-query'
import { ProductList } from '@modules/product-list'
function Page() {
const { data: products } = useQuery(['products'], fetchProducts)
return <ProductList products={products} />
}
문제점:
- 사용처에서 react-query를 알아야 함
- 모듈과 사용처 간 결합도 높음
- 모듈 교체 시 사용처도 수정 필요
✅ 해결 방법
1. Provider Level에서 하이드레이트 대응
핵심 아이디어:
- Provider level에서 SSR 데이터를 React Query 캐시에 하이드레이트
- 사용처에서는 환경을 신경 쓰지 않고
useQuery만 사용
구현:
// CorePackProvider.tsx
import {
QueryClient,
QueryClientProvider,
dehydrate,
Hydrate,
} from '@tanstack/react-query'
export function CorePackProvider({
children,
dehydratedState,
}: CorePackProviderProps) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1분
cacheTime: 5 * 60 * 1000, // 5분
},
},
})
)
return (
<QueryClientProvider client={queryClient}>
<Hydrate state={dehydratedState}>{children}</Hydrate>
</QueryClientProvider>
)
}
페이지 레벨에서 사용:
// pages/home.tsx
export async function getStaticProps() {
const queryClient = new QueryClient()
// SSR에서 데이터 prefetch
await queryClient.prefetchQuery(['products'], fetchProducts)
await queryClient.prefetchQuery(['user'], fetchUser)
return {
props: {
dehydratedState: dehydrate(queryClient),
},
}
}
export default function HomePage({ dehydratedState }) {
return (
<CorePackProvider dehydratedState={dehydratedState}>
<HomePageContent />
</CorePackProvider>
)
}
효과:
- ✅ SSR 데이터가 React Query 캐시에 하이드레이트됨
- ✅ CSR 환경에서도 동일한 방식으로 접근 가능
- ✅ 환경 전환 시 코드 수정 불필요
2. 쿼리 키와 함수 관리 → prefetch/useQuery에서 사용
핵심 원칙: 쿼리 키와 queryFn을 함께 묶어서 prefetch와 useQuery에서 동일하게 사용하면 캐시 미스가 발생하지 않습니다.
구조:
- 쿼리 키와 함수 관리: 쿼리 객체로 묶어서 정의
- prefetch/useQuery에서 사용: 정의한 쿼리 객체를 재사용
1단계: 쿼리 키와 함수 관리
쿼리 객체로 묶기 (queries/products.ts):
// queries/products.ts
import { QueryOptions } from '@tanstack/react-query'
export const productsQuery = {
queryKey: ['products'] as const,
queryFn: fetchProducts,
} satisfies QueryOptions
export const productDetailQuery = (id: number) =>
({
queryKey: ['products', 'detail', id] as const,
queryFn: () => fetchProductDetail(id),
} satisfies QueryOptions)
export const productsListQuery = (filters?: string) =>
({
queryKey: ['products', 'list', { filters }] as const,
queryFn: () => fetchProducts(filters),
} satisfies QueryOptions)
효과:
- ✅ prefetch와 useQuery에서 동일한 객체 사용으로 캐시 미스 방지
- ✅ 쿼리 키와 queryFn이 항상 일치 보장
- ✅ 타입 안전성 확보
- ✅ 쿼리 정의 변경 시 한 곳만 수정
2단계: prefetch와 useQuery에서 사용
BOLD_PAREN_PLACEHOLDER_3:
// hooks/useProductsPrefetch.ts
import { QueryClient } from '@tanstack/react-query'
import {
productsQuery,
productDetailQuery,
productsListQuery,
} from '../queries/products'
export async function prefetchProducts(
queryClient: QueryClient,
filters?: string
) {
// prefetch에서도 동일한 쿼리 객체 사용
return queryClient.prefetchQuery({
...productsListQuery(filters),
staleTime: 60 * 1000,
})
}
export async function prefetchProductDetail(
queryClient: QueryClient,
id: number
) {
// prefetch에서도 동일한 쿼리 객체 사용
return queryClient.prefetchQuery({
...productDetailQuery(id),
staleTime: 60 * 1000,
})
}
BOLD_PAREN_PLACEHOLDER_4:
// hooks/useProductsQuery.ts
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import {
productsQuery,
productDetailQuery,
productsListQuery,
} from '../queries/products'
export function useProductsQuery(
filters?: string,
options?: Omit<UseQueryOptions, 'queryKey' | 'queryFn'>
) {
// useQuery에서도 동일한 쿼리 객체 사용 → 캐시 미스 방지!
return useQuery({
...productsListQuery(filters),
staleTime: 60 * 1000,
...options,
})
}
export function useProductDetailQuery(
id: number,
options?: Omit<UseQueryOptions, 'queryKey' | 'queryFn'>
) {
// useQuery에서도 동일한 쿼리 객체 사용 → 캐시 미스 방지!
return useQuery({
...productDetailQuery(id),
staleTime: 60 * 1000,
...options,
})
}
컴포넌트에서 사용하는 hook:
// hooks/useProducts.ts
import { useProductsQuery, useProductDetailQuery } from './useProductsQuery'
export function useProducts(filters?: string) {
const { data, isLoading, error } = useProductsQuery(filters)
return {
products: data ?? [],
isLoading,
error,
}
}
export function useProduct(id: number) {
const { data, isLoading, error } = useProductDetailQuery(id)
return {
product: data,
isLoading,
error,
}
}
사용 예시:
// 컴포넌트에서 사용
function ProductList({ filters }: { filters?: string }) {
const { products, isLoading } = useProducts(filters)
if (isLoading) return <Loading />
return (
<div>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
)
}
function ProductDetail({ id }: { id: number }) {
const { product, isLoading } = useProduct(id)
if (isLoading) return <Loading />
if (!product) return <NotFound />
return <ProductDetailView product={product} />
}
효과:
- ✅ props drilling 제거
- ✅ 컴포넌트가 필요한 데이터를 직접 가져옴
- ✅ 중간 컴포넌트는 props 전달 불필요
- ✅ prefetch와 useQuery에서 동일한 쿼리 객체 사용으로 캐시 미스 방지
- ✅ 쿼리 키와 queryFn이 항상 일치하여 캐시 히트 보장
3. 모듈 독립성 확보
BOLD_PAREN_PLACEHOLDER_5:
// 사용처
import { useQuery } from '@tanstack/react-query'
import { ProductList } from '@modules/product-list'
function Page() {
const { data: products } = useQuery(['products'], fetchProducts)
return <ProductList products={products} />
}
BOLD_PAREN_PLACEHOLDER_6:
// 모듈 내부 (ProductList/index.tsx)
import { useProducts } from './hooks/useProducts'
export function ProductList() {
const { products, isLoading } = useProducts()
if (isLoading) return <Loading />
return (
<div>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
)
}
// 사용처
import { ProductList } from '@modules/product-list'
function Page() {
// react-query를 직접 사용하지 않음!
return <ProductList />
}
효과:
- ✅ 사용처에서 react-query 디펜던시 제거
- ✅ 모듈 독립성 확보
- ✅ 모듈 교체 시 사용처 수정 불필요
4. SSR/CSR 통합 처리
통합된 데이터 페칭 로직:
// hooks/useProducts.ts
import { useProductsQuery } from './useProductsQuery'
export function useProducts(filters?: string) {
// SSR에서 prefetch된 데이터가 있으면 사용
// 없으면 CSR에서 fetch
// prefetch와 동일한 쿼리 객체 사용으로 캐시 히트 보장
const { data, isLoading, error } = useProductsQuery(filters)
return {
products: data ?? [],
isLoading,
error,
}
}
페이지 레벨에서 prefetch:
// pages/home.tsx
import { QueryClient } from '@tanstack/react-query'
import { dehydrate } from '@tanstack/react-query'
import { prefetchProducts } from '../hooks/useProductsPrefetch'
import { prefetchUserProfile } from '../hooks/useUserPrefetch'
export async function getStaticProps() {
const queryClient = new QueryClient()
// SSR에서 prefetch (중앙 관리된 쿼리 키와 함수 사용)
await prefetchProducts(queryClient)
await prefetchUserProfile(queryClient)
return {
props: {
dehydratedState: dehydrate(queryClient),
},
revalidate: 60, // ISR
}
}
효과:
- ✅ SSR과 CSR에서 동일한 코드 사용
- ✅ 환경 전환 시 코드 수정 불필요
- ✅ ISR과도 자연스럽게 통합
🏗️ 아키텍처
변경 전 구조
Page Component
↓ (props drilling)
Layout Component
↓ (props drilling)
MainContent Component
↓ (props drilling)
ProductList Component (실제 사용)
변경 후 구조
1. 쿼리 키와 함수 관리
queries/products.ts
└─ productsQuery (queryKey + queryFn 묶음)
2. prefetch/useQuery에서 사용
Page Component
↓ (prefetch - 동일한 쿼리 객체 사용)
CorePackProvider (하이드레이트)
↓
ProductList Component
↓ (useQuery - 동일한 쿼리 객체 사용)
React Query Cache (캐시 히트 보장!)
📈 개선 효과
| 항목 | Before | After | 개선 |
|---|---|---|---|
| Props 전달 레벨 | 3-4단계 | 0단계 | 100% 제거 |
| 중간 컴포넌트 수정 | 필요 | 불필요 | 수정 범위 감소 |
| 코드 가독성 | 낮음 | 높음 | 향상 |
| 모듈 독립성 | 낮음 | 높음 | 향상 |
| 환경 전환 | 코드 수정 필요 | 수정 불필요 | 유연성 향상 |
💡 핵심 교훈
1. Provider Level에서 하이드레이트가 핵심
SSR 데이터를 React Query 캐시에 하이드레이트하면, CSR 환경에서도 동일한 방식으로 접근할 수 있습니다. 이는 환경 전환 시 코드 수정을 최소화하는 핵심입니다.
2. 쿼리 키와 queryFn을 함께 묶어서 사용해야 함
핵심: prefetch와 useQuery에서 동일한 쿼리 객체(queryKey + queryFn)를 사용해야 캐시 미스가 발생하지 않습니다.
문제 상황:
// ❌ 잘못된 예: prefetch와 useQuery에서 다른 방식으로 정의
// prefetch에서
await queryClient.prefetchQuery({
queryKey: ['products'],
queryFn: fetchProducts,
})
// useQuery에서 (쿼리 키나 함수가 조금이라도 다르면 캐시 미스!)
const { data } = useQuery({
queryKey: ['products'], // 동일하지만
queryFn: () => fetchProducts(), // 화살표 함수로 감싸면 다른 함수로 인식!
})
해결 방법:
// ✅ 올바른 예: 쿼리 객체를 묶어서 재사용
const productsQuery = {
queryKey: ['products'],
queryFn: fetchProducts,
}
// prefetch에서
await queryClient.prefetchQuery(productsQuery)
// useQuery에서 (동일한 객체 사용)
const { data } = useQuery(productsQuery)
이렇게 하면 prefetch에서 준비한 데이터를 useQuery에서 정확히 찾을 수 있어 캐시 히트가 보장됩니다.
3. 쿼리 키와 함수를 먼저 관리하고 prefetch/useQuery에서 사용하는 구조가 효과적
쿼리 키와 queryFn을 먼저 묶어서 관리하고, prefetch와 useQuery에서 동일한 쿼리 객체를 사용하면 캐시 일관성이 보장됩니다. 이렇게 하면 각 레이어의 책임이 명확해지고 재사용성이 높아집니다.
4. 모듈 독립성을 위해 내부에서 처리
모듈이 필요한 데이터를 내부에서 가져오도록 하면, 사용처의 디펜던시를 제거할 수 있고 모듈 독립성이 향상됩니다.
5. 타입 안전성을 유지해야 함
React Query의 타입을 활용하여 타입 안전성을 유지하는 것이 중요합니다. useQuery의 제네릭 타입을 명시적으로 지정하고, 쿼리 키를 as const로 정의하면 타입 체크가 강화됩니다.
6. 캐싱 전략을 신중하게 설계해야 함
staleTime, cacheTime 등을 적절히 설정하여 불필요한 API 호출을 방지하고, 사용자 경험을 향상시켜야 합니다. prefetch와 useQuery에서 동일한 쿼리 객체를 사용하면 캐시 히트가 보장되어 SSR 데이터를 효율적으로 활용할 수 있습니다.
🎯 결론
React Query 레이어 구축을 통해 다음과 같은 성과를 달성했습니다:
- ✅ Props Drilling 완전 제거: 컴포넌트가 필요한 데이터를 직접 가져옴
- ✅ 캐시 미스 방지: prefetch와 useQuery에서 동일한 쿼리 객체 사용으로 캐시 히트 보장
- ✅ SSR/CSR 환경 통합: 동일한 코드로 두 환경 모두 지원
- ✅ 모듈 독립성 확보: 사용처 react-query 디펜던시 제거
- ✅ 코드 가독성 향상: 중간 컴포넌트의 불필요한 props 전달 제거
- ✅ 유지보수성 향상: 환경 전환 시 코드 수정 최소화, 쿼리 객체 변경 시 한 곳만 수정
이번 작업을 통해 대규모 모노레포에서의 상태 관리와 데이터 페칭 아키텍처 설계 경험을 쌓았고, React Query의 prefetch, 하이드레이트, 캐싱 전략, 쿼리 키와 queryFn을 함께 묶어서 사용하는 패턴 등을 실무에 적용할 수 있었습니다.
